react router英文官网:https://reactrouter.com/en/main
祝大家:开心学习,快乐生活,过好每一天~
1、打开终端,运行下面的命令创建React+TS项目:
xxxxxxxxxx51# 初始化项目2npm create vite@latest3# 填写项目名称4# 框架选择 react5# 语言选择 typescript2、cd到项目根目录中
3、运行npm i命令,安装依赖包
4、运行npm run dev命令,启动项目
5、查看项目运行效果
整套教程其实是react router官网的tutorial的改写,加了部分注释和typescript,所以下面的很多代码可以在这里找到:https://reactrouter.com/en/main/start/tutorial。比如说下面要用到的CSS和js代码,就可以直接复制粘贴。

那是不是这门课买错了呢?没有买错,谁叫我看不懂英文文档的,中文文档我也提炼不出好的知识点。买这门课绝对没有错。
1、复制/粘贴教程所需的CSS样式到src/index.css中。
代码很多,直接复制即可,这里就不粘贴出来了。
2、复制/粘贴教程所需的Data数据到src/contacts.js中。在我们的项目中,老师建议使用老师改造好的TS类型的数据模块,复制/粘贴下面的代码到src/contacts.ts中。
xxxxxxxxxx801import localforage from 'localforage'2import { matchSorter } from 'match-sorter'3import sortBy from 'sort-by'45// 获取所有联系人6export async function getContacts(query?: string) {7 await fakeNetwork(`getContacts:${query}`)8 let contacts = await localforage.getItem<ContactType[]>('contacts')9 if (!contacts) contacts = []10 if (query) {11 contacts = matchSorter(contacts, query.toString(), { keys: ['first', 'last'] })12 }13 return contacts.sort(sortBy('last', 'createdAt'))14}1516// 创建联系人,返回联系人对象,只包含 id 和创建时间17export async function createContact() {18 await fakeNetwork()19 const id = Math.random().toString(36).substring(2, 9)20 const contact = { id, createdAt: Date.now() }21 const contacts = await getContacts()22 contacts.unshift(contact)23 await set(contacts)24 return contact25}2627// 根据 id 获取联系人28export async function getContact(id: string) {29 await fakeNetwork(`contact:${id}`)30 const contacts = (await localforage.getItem<ContactType[]>('contacts')) || []31 const contact = contacts.find((contact) => contact.id === id)32 return contact ?? null33}3435// 根据 id 和表单数据,更新联系人信息36export async function updateContact(id: string, updates: Omit<ContactType, 'id'>) {37 await fakeNetwork()38 const contacts = (await localforage.getItem<ContactType[]>('contacts')) || []39 const contact = contacts.find((contact) => contact.id === id)40 if (!contact) throw new Error('No contact found for')41 Object.assign(contact, updates)42 await set(contacts)43 return contact44}4546// 根据 id 删除联系人47export async function deleteContact(id: string) {48 const contacts = (await localforage.getItem<ContactType[]>('contacts')) || []49 const index = contacts.findIndex((contact) => contact.id === id)50 if (index > -1) {51 contacts.splice(index, 1)52 await set(contacts)53 return true54 }55 return false56}5758function set(contacts: ContactType[]) {59 return localforage.setItem('contacts', contacts)60}6162// fake a cache so we don't slow down stuff we've already seen63let fakeCache: { [key in string]: boolean } = {}6465// 模拟网络请求的延迟时间66async function fakeNetwork(key?: string) {67 if (!key) {68 fakeCache = {}69 key = ''70 }7172 if (fakeCache[key]) {73 return74 }7576 key && (fakeCache[key] = true)77 return new Promise((res) => {78 setTimeout(res, Math.random() * 800)79 })80}在src/vite-env.d.ts模块中,新增ContactType类型定义:
xxxxxxxxxx121/// <reference types="vite/client" />23type ContactType = {4 id: string;5 first?: string;6 last?: string;7 avatar?: string;8 twitter?: string;9 notes?: string;10 favorite?: boolean;11};123、安装相关的依赖包:
xxxxxxxxxx21npm i react-router-dom localforage match-sorter sort-by2npm i --save-dev @types/sort-by4、删除src/App.tsx和src/App.css文件
5、修改src/main.tsx文件,删除App组件相关的代码:
xxxxxxxxxx111import React from 'react'2import ReactDOM from 'react-dom/client'3// import App from './App.tsx'4import './index.css'56ReactDOM.createRoot(document.getElementById('root')!).render(7 <React.StrictMode>8 {/* <App /> */}9 <h1>Tutorial</h1>10 </React.StrictMode>,11)完成后效果:

1、在src/main.tsx中,按需导入createBrowserRouter函数和RouterProvider组件:
xxxxxxxxxx21// 1、导入 createBrowserRouter 和 RouterProvider2import { createBrowserRouter, RouterProvider } from 'react-router-dom'2、创建browser router对象:
xxxxxxxxxx81// 2、创建 browser router2const router = createBrowserRouter([3 // 配置路由4 {5 path: '/',6 element: <h1>Hello react-router v6!</h1>7 }8])3、基于RouterProvider组件的router属性,配置项目的路由:
xxxxxxxxxx51ReactDOM.createRoot(document.getElementById('root')!).render(2 <React.StrictMode>3 <RouterProvider router={router} />4 </React.StrictMode>,5)效果:

实际开发中,选择Browser Rotuer还是Hash Router?
1、如果要兼容低版本的浏览器,则推荐使用 Hash Router。
2、否则,建议使用 Browser Router,因为它功能更强大,能够使用浏览器的历史对象管理路由信息。
1、创建src/components下的root.tsx模块
2、复制/粘贴如下的组件代码到src/routes/root.tsx中:
xxxxxxxxxx471import { FC } from 'react'23const Root: FC = () => {4 return (5 <>6 <div id="sidebar">7 <h1>React Router Contacts</h1>8 <div>9 <form id="search-form" role="search">10 <input11 id="q"12 aria-label="Search contacts"13 placeholder="Search"14 type="search"15 name="q"16 />17 <div18 id="search-spinner"19 aria-hidden20 hidden={true}21 />22 <div23 className="sr-only"24 aria-live="polite"25 ></div>26 </form>27 <form method="post">28 <button type="submit">New</button>29 </form>30 </div>31 <nav>32 <ul>33 <li>34 <a href={`/contacts/1`}>Your Name</a>35 </li>36 <li>37 <a href={`/contacts/2`}>Your Friend</a>38 </li>39 </ul>40 </nav>41 </div>42 <div id="detail"></div>43 </>44 );45}4647export default Root;3、修改src/main.tsx文件,导入Root组件,并渲染为/路径的element节点:
xxxxxxxxxx221import React from 'react'2import ReactDOM from 'react-dom/client'3import './index.css'4import { createBrowserRouter, RouterProvider } from 'react-router-dom'56// 1、导入路由组件7import Root from './components/root'89const router = createBrowserRouter([10 // 2、配置路由11 {12 path: '/',13 element: <Root />14 }15])161718ReactDOM.createRoot(document.getElementById('root')!).render(19 <React.StrictMode>20 <RouterProvider router={router} />21 </React.StrictMode>,22)效果:

注意:如果您想用
@指向src/目录,需要配置项目下的vite.config.ts和tsconfig.json文件。
xxxxxxxxxx11npm i -D @types/node为什么需要配置@路径提示?
因为这样很方便,在引入外部文件的时候,一般使用的是相对路径,但是相对路径不是明确,接手的人不容易找到文件所在位置。
如果是按照src目录下来找,就会很明确,依次查找文件即可。
项目创建后,默认使用的就是相对路径。
vite.config.ts 文件:xxxxxxxxxx131// 1. 以 ES6 模块化的方式,从 Node 的 path 模块中,导入 join 函数2import { join } from 'path'34// https://vitejs.dev/config/5export default defineConfig({6 plugins: [react()],7 // 2. 在 resolve.alias 对象下,配置 @ 的指向路径8 resolve: {9 alias: {10 '@': join(__dirname, './src/')11 }12 }13})
只需要添加代码即可,原有的代码不需要动。
tsconfig.json 文件,在 compilerOptions 节点下,新增 "baseUrl": "." 和 "paths": { "@/*": [ "src/*" ] } 两项:xxxxxxxxxx401{2 "compilerOptions": {3 /* 新增以下两个配置项,分别是 baseUrl 和 paths */4 "baseUrl": ".",5 "paths": {6 "@/*": [7 "src/*"8 ]9 },10 "target": "ES2020",11 "useDefineForClassFields": true,12 "lib": [13 "ES2020",14 "DOM",15 "DOM.Iterable"16 ],17 "module": "ESNext",18 "skipLibCheck": true,19 /* Bundler mode */20 "moduleResolution": "bundler",21 "allowImportingTsExtensions": true,22 "resolveJsonModule": true,23 "isolatedModules": true,24 "noEmit": true,25 "jsx": "react-jsx",26 /* Linting */27 "strict": true,28 "noUnusedLocals": true,29 "noUnusedParameters": true,30 "noFallthroughCasesInSwitch": true31 },32 "include": [33 "src"34 ],35 "references": [36 {37 "path": "./tsconfig.node.json"38 }39 ]40}这样,就可以使用@来表示/src/目录了。

启动项目,没有问题。
目前,点击左侧任意的导航链接,会导航到 react router 默认提供的错误页。
为了使用户体验更好,我们在进行项目开发时,一般都会自定义错误页。
1、创建src/error-page.tsx错误页,对应的组件如下:
xxxxxxxxxx151import { FC } from 'react'23const ErrorPage: FC = () => {4 return (5 <div id='error-page'>6 <h1>Oops!</h1>7 <p>Sorry, an unexpected error has occurred.</p>8 <p>9 <i>**具体的错误信息**</i>10 </p>11 </div>12 )13}1415export default ErrorPage
2、把<ErrorPage />设置为根路由的errorElement属性:
xxxxxxxxxx241import React from 'react'2import ReactDOM from 'react-dom/client'3import '@/index.css'4import { createBrowserRouter, RouterProvider } from 'react-router-dom'5import Root from '@/components/root'67// 1、导入自定义的 组件8import ErrorPage from '@/error-page'910const router = createBrowserRouter([11 // 2、errorElement 指定错误页12 {13 path: '/',14 element: <Root />,15 errorElement: <ErrorPage />16 }17])181920ReactDOM.createRoot(document.getElementById('root')!).render(21 <React.StrictMode>22 <RouterProvider router={router} />23 </React.StrictMode>,24)此时,点击左侧任意的导航链接,当无法匹配任何路由时,就会展示根路由的errorElement属性所匹配的<ErrorPage />错误页。
效果:

3、为了能在ErrorPage中展示具体的错误消息,我们需要使用react-router-dom提供的useRouteErrorhook:
xxxxxxxxxx201import { FC } from 'react'2import { useRouteError } from 'react-router-dom'3import type { ErrorResponse } from "react-router-dom"45const ErrorPage: FC = () => {6 // 这里的路由错误的类型本身就只有ErrorResponse,但是由于错误可能是多种原因造成的,特别是后面的案例中会手动抛出一个错误 throw new Error,所以这里就增加了错误的类型。7 const error = useRouteError() as ErrorResponse & Error89 return (10 <div id='error-page'>11 <h1>Oops!</h1>12 <p>Sorry, an unexpected error has occurred.</p>13 <p>14 <i>{error.statusText || error.message}</i>15 </p>16 </div>17 )18}1920export default ErrorPage效果:

1、在src/components目录下新建contact.tsx模块,并把对应的UI结构复制/粘贴进去:
xxxxxxxxxx951import { FC } from 'react'2import { Form } from "react-router-dom";34const Contact: FC = () => {5 // 静态数据6 const contact: ContactType = {7 id: "",8 first: "Your",9 last: "Name",10 avatar: "https://placekitten.com/g/200/200",11 twitter: "your_handle",12 notes: "Some notes",13 favorite: true,14 };1516 return (17 <div id="contact">18 <div>19 <img20 key={contact.avatar}21 src={contact.avatar || ''}22 />23 </div>2425 <div>26 <h1>27 {contact.first || contact.last ? (28 <>29 {contact.first} {contact.last}30 </>31 ) : (32 <i>No Name</i>33 )}{" "}34 <Favorite contact={contact} />35 </h1>3637 {contact.twitter && (38 <p>39 <a40 target="_blank"41 href={`https://twitter.com/${contact.twitter}`}42 >43 {contact.twitter}44 </a>45 </p>46 )}4748 {contact.notes && <p>{contact.notes}</p>}4950 <div>51 <Form action="edit">52 <button type="submit">Edit</button>53 </Form>54 <Form55 method="post"56 action="destroy"57 onSubmit={(event) => {58 if (59 !confirm(60 "Please confirm you want to delete this record."61 )62 ) {63 event.preventDefault();64 }65 }}66 >67 <button type="submit">Delete</button>68 </Form>69 </div>70 </div>71 </div>72 );73}7475const Favorite: FC<{ contact: ContactType }> = ({ contact }) => {76 // yes, this is a `let` for later77 const favorite = contact.favorite;78 return (79 <Form method="post">80 <button81 name="favorite"82 value={favorite ? "false" : "true"}83 aria-label={84 favorite85 ? "Remove from favorites"86 : "Add to favorites"87 }88 >89 {favorite ? "★" : "☆"}90 </button>91 </Form>92 );93}9495export default Contact;2、修改src/main.tsx中的代码,导入Contact组件:
xxxxxxxxxx11import Contact from '@/components/contact'并添加路由配置如下:
xxxxxxxxxx121const router = createBrowserRouter([2 {3 path: '/',4 element: <Root />,5 errorElement: <ErrorPage />6 },7 // :id 表示这里是动态路由,接收的动态路由参数是 id。这里其实是将 contacts/:id 路由放到了根路由 / 的同级。8 {9 path: "contacts/:id",10 element: <Contact />11 }12])效果:

需求:我们希望把 Contact 组件渲染到 Root 组件中右侧的位置,而不是像上面那样,是整个页面。具体位置是:把 Contact 组件渲染到Root组件的 id 为 detail 的 div 中。
1、修改src/main.tsx中的代码,通过 children 属性把contacts/:id的Route定义为/Route的子路由:
xxxxxxxxxx151const router = createBrowserRouter([2 {3 path: '/',4 element: <Root />,5 errorElement: <ErrorPage />,6 // 子路由7 children: [8 {9 // 这里的contacts前面不要加 / 开头,这是react-router-dom官方建议的。因为在访问这个路由的时候,react-router会将前面所有父级的路由拼接到前面,所以这里不要带父级的path。10 path: "contacts/:id",11 element: <Contact />12 }13 ]14 },15])2、修改src/components/root.tsx中的代码,从react-router-dom中导入Outlet组件(占位符):
xxxxxxxxxx11import { Outlet } from 'react-router-dom'并把它放到id属性为detail的div中:
xxxxxxxxxx31<div id="detail">2 <Outlet />3</div>效果:

在上面的例子中,可以看到跳转链接的时候,浏览器的刷新按钮会刷新,那么在现代前端框架下,我们都是使用无刷新的路由跳转,所以可以使用Link代替普通的a链接。
1、在src/components/root.tsx中导入Link组件
xxxxxxxxxx11import { Link } from "react-router-dom"2、使用Link代替普通的a链接
xxxxxxxxxx121<nav>2 <ul>3 <li>4 {/* <a href={`/contacts/1`}>Your Name</a> */}5 <Link to="/contacts/1">Your Name</Link>6 </li>7 <li>8 {/* <a href={`/contacts/2`}>Your Friend</a> */}9 <Link to="/contacts/2">Your Friend</Link>10 </li>11 </ul>12</nav>效果:

可以看到,无论路由怎么切换,浏览器的刷新按钮都不会动。
这部分的内容是非常新的内容,无论是vue还是react尚硅谷版本,都没有这个内容,以往学习路由的时候,只是要求页面能够跳转即可,数据就在vue生命周期或者react生命周期中处理,在vue中这样写是没有问题的,但是在react中,特别是使用hooks的时候,这样写就会非常麻烦,重复代码也很多,所以就出现了data APIs。多学几遍。
了解路由中的data APIs:

1、在src/components/root.tsx中,Root组件平级的位置定义并暴露出loader函数:
xxxxxxxxxx71// 定义当前路由组件,需要用到的 loader 函数2export const loader = () => {3 // 为了证明 loader 函数在挂载后确实执行了,这里输出一句话4 console.log('loader执行了')5 // loader函数必须要return一个 null或数据,不能return undefined6 return null7}
2、在src/main.tsx中,导入并重命名loader,并挂载到路由上:
xxxxxxxxxx181// 导入loader函数,并重命名2import Root, { loader as rootLoader } from '@/components/root'34const router = createBrowserRouter([5 {6 path: '/',7 element: <Root />,8 errorElement: <ErrorPage />,9 // 配置 loader 属性10 loader: rootLoader,11 children: [12 {13 path: "contacts/:id",14 element: <Contact />15 }16 ]17 },18])效果:

结果说明:在进入root组件之前,会先调用loader函数,再渲染组件。
3、使用react-router-dom提供的useLoaderData这个hook来获取loader函数返回的数据:

效果:

1、修改src/components/root.tsx模块,先从src/contacts.ts中按需导入数据API:
xxxxxxxxxx11import { getContacts } from "@/contacts.ts"再创建名为的函数,并向外按需导出:
xxxxxxxxxx81// 下面这段代码是为了消除掉loader下面的波浪线提示的2/* eslint-disable react-refresh/only-export-components */34// 定义当前路由组件,需要用到的 loader 函数。5export const loader = async () => {6 const contacts = await getContacts();7 return contacts8}2、修改src/main.tsx模块,先从src/components/root.tsx按需导入loader函数:
xxxxxxxxxx11import Root, { loader as rootLoader } from '@/components/root'再给对应的Route添加选项:
xxxxxxxxxx151const router = createBrowserRouter([2 {3 path: '/',4 element: <Root />,5 errorElement: <ErrorPage />,6 // 配置 loader 属性7 loader: rootLoader,8 children: [9 {10 path: "contacts/:id",11 element: <Contact />12 }13 ]14 },15])3、在src/components/root.tsx组件中,使用useLoaderData获取loader返回的数据,并根据返回的数据渲染组件:
xxxxxxxxxx681import { FC } from 'react'2import { Outlet, Link, useLoaderData } from 'react-router-dom'3import { getContacts } from '@/contacts'45const Root: FC = () => {6 // 获取从loader返回的数据7 const contacts = useLoaderData() as ContactType[];8 console.log('contacts = ',contacts)910 // 定义根据contacts渲染的元素11 const renderContacts = () => {12 if (contacts.length === 0) {13 return <p><i>No contacts!</i></p>14 }15 return (16 <ul>17 {18 contacts.map((item) => {19 return <li key={item.id}>20 <Link to={`/contacts/${item.id}`}>21 {item.first || item.last ? <>{item.first} {item.last}</> : <i>No Name</i>}22 {item.favorite && <span>☆</span>}23 </Link>24 </li>25 })26 }27 </ul>28 )29 }3031 return (32 <>33 <div id="sidebar">34 <h1>React Router Contacts</h1>35 <div>36 <form id="search-form" role="search">37 <input38 id="q"39 aria-label="Search contacts"40 placeholder="Search"41 type="search"42 name="q"43 />44 <div45 id="search-spinner"46 aria-hidden47 hidden={true}48 />49 <div50 className="sr-only"51 aria-live="polite"52 ></div>53 </form>54 <form method="post">55 <button type="submit">New</button>56 </form>57 </div>58 <nav>59 {/* 执行渲染函数 */}60 {renderContacts()}61 </nav>62 </div>63 <div id="detail">64 <Outlet />65 </div>66 </>67 );68}效果:

需求:在进行CURD操作时,把操作提交到action中进行处理。
可以预料到,如果一个大型项目中,使用Form来提交操作的话,会导致router非常臃肿,虽然可以通过拆分的方式来缓解,但是这种编码方式是一种全新的挑战,真正做项目之前看一下别人的案例,做就行了。
1、这里就需要使用到react-router-dom提供的Form组件,将src/components/root.tsx中原来的form组件换为Form组件。
xxxxxxxxxx61import { Outlet, Link, useLoaderData,Form } from 'react-router-dom'23{/* 新增联系人的 form 表单。这里的 form 由于要提交到action进行处理,所以换为了 react-router-dom 的Form 。那么这里面的action就表示提交到哪个 path 下的action进行处理,如果省略了action,那么这段代码所在的组件属于哪个路由,就会提交到哪个路由的action进行处理。 */}4<Form method="post" action='/'>5 <button type="submit">New</button>6</Form>2、在src/components/root.tsx中,创建并暴露出一个action函数:
xxxxxxxxxx41export const action = () => {2 console.log('action')3 return null4}3、在src/main.tsx中,导入并将配置根路由的action属性:
xxxxxxxxxx191// 导入 action 函数,并重命名2import Root, { loader as rootLoader, action as rootAction } from '@/components/root'34const router = createBrowserRouter([5 {6 path: '/',7 element: <Root />,8 errorElement: <ErrorPage />,9 loader: rootLoader,10 // 配置 action 属性11 action: rootAction,12 children: [13 {14 path: "contacts/:id",15 element: <Contact />16 }17 ]18 },19])效果:

4、使用contacts.ts中定义好的新增方法,在定义的action函数中执行这个方法:
xxxxxxxxxx61import { getContacts,createContact } from '@/contacts'23export const action = async () => {4 const contact = await createContact();5 return contact6}点击New按钮,看一下效果:

可以看到新增了两个no name的contacts,并且新增之后会自动渲染到sidebar上面,为什么呢?
因为在action执行完成之后,会重新执行loader函数,并重新渲染组件。口说无凭,在定义的loader和action函数里面输出一些东西,看一下效果:

注意:action函数中是否需要 return 数据?
上面点击按钮后,实际上渲染的是从loader中拿到的数据,并不是从action中拿到的数据,那action中return出去的数据,到哪去了呢?
实际上return到了路由中action属性所在的path指向的组件中。使用react-router-dom提供的
useActionData就可以拿到这个数据。测试一下:
xxxxxxxxxx141import { FC } from 'react'2import { Outlet, Link, useLoaderData, Form, useActionData } from 'react-router-dom'3import { getContacts, createContact } from '@/contacts'45const Root: FC = () => {67const contacts = useLoaderData() as ContactType[];8// console.log('contacts = ', contacts)910const data = useActionData();11console.log('action = ',data)1213// 其余代码省略14}效果:
但其实在action函数中,如果不是特殊情况要从
useActionData()里面获取数据的话,是没有必要在action函数中return数据出去的,所以一般的action函数写成这样:xxxxxxxxxx41export const action = async () => {2await createContact();3return null4}
下面说明一下action的执行流程:


目前,
contact.tsx组件中的联系人信息是写死的静态数据。当使用路由加载此组件时,我们应该在loader中获取联系人的id,并动态请求联系人的数据进行渲染。
1、修改src/components/contact.tsx组件,从react-router-dom中按需导入LoaderFunctionArgs这个TS类型,它表示loader函数的形参类型:
xxxxxxxxxx41// 这种导入方式是导入 组件或方法2import { Form } from "react-router-dom"3// 这种导入方式是导入 TS类型,其实这里的 type 可以省略,但为了代码清晰,还是可以作区分的。4import type { LoaderFunctionArgs } from "react-router-dom"然后,从src/contacts.ts中导入需要的数据API:
xxxxxxxxxx11import { getContact } from "@/contacts.ts"最后,创建并向外导出名为的函数:
xxxxxxxxxx61// 通过 params 可以访问到 Route path 中的动态参数2export const loader = async ({ params }: LoaderFunctionArgs) => {3 // 调用接口,获取用户数据。注意:params.id! 这里的 ! 表示类型推断排除null、undefined,也就是说确认不可能为空。参考:https://blog.csdn.net/weixin_44867717/article/details/1209047284 const contact = await getContact(params.id!);5 return { contact }6}2、修改src/main.tsx组件,从src/routes/contact.tsx中按需导入loader后重命名为contactLoader:
xxxxxxxxxx11import Contact, { loader as contactLoader } from '@/components/contact'把contactLoader挂载给path为contacts/:id的Route:
xxxxxxxxxx161const router = createBrowserRouter([2 {3 path: '/',4 element: <Root />,5 errorElement: <ErrorPage />,6 loader: rootLoader,7 action: rootAction,8 children: [9 {10 path: "contacts/:id",11 element: <Contact />,12 loader: contactLoader13 }14 ]15 },16])3、修改src/components/contacts.tsx组件,从react-router-dom中按需导入useLoaderData这个hook:
xxxxxxxxxx11import { Form, LoaderFunctionArgs, useLoaderData } from "react-router-dom";修改Contact组件,将组件内部写死的静态数据,替换为useLoaderData的调用,从而根据id获取联系人的信息:
xxxxxxxxxx61const Contact: FC = () => {2 // 获取 loader 返回的数据3 const { contact } = useLoaderData() as { contact: ContactType };45 // 省略其余代码6}效果:

从上面的例子可以看到,loader和路由是对应的关系,和对应的组件也是一一对应的关系,那么虽然使用的都是
useLoaderData(),拿到的数据却是准确的,这一点要理解清楚。
1、在src/components目录下新建edit.tsx组件,代码如下:
xxxxxxxxxx721import { FC } from 'react'2// 按需导入组件和hook3import { Form, useLoaderData } from "react-router-dom";4import type { LoaderFunctionArgs } from 'react-router-dom';5import { getContact } from '@/contracts';67const EditContact: FC = () => {8 // 获取联系人的数据,用来回显9 const { contact } = useLoaderData() as { contact: ContactType };1011 return (12 // 注意:这里使用的是 react-router-dom 提供的 Form 组件13 <Form method="post" id="contact-form">14 <p>15 <span>Name</span>16 <input17 placeholder="First"18 aria-label="First name"19 type="text"20 name="first"21 defaultValue={contact.first}22 />23 <input24 placeholder="Last"25 aria-label="Last name"26 type="text"27 name="last"28 defaultValue={contact.last}29 />30 </p>31 <label>32 <span>Twitter</span>33 <input34 type="text"35 name="twitter"36 placeholder="@jack"37 defaultValue={contact.twitter}38 />39 </label>40 <label>41 <span>Avatar URL</span>42 <input43 placeholder="https://example.com/avatar.jpg"44 aria-label="Avatar URL"45 type="text"46 name="avatar"47 defaultValue={contact.avatar}48 />49 </label>50 <label>51 <span>Notes</span>52 <textarea53 name="notes"54 defaultValue={contact.notes}55 rows={6}56 />57 </label>58 <p>59 <button type="submit">Save</button>60 <button type="button">Cancel</button>61 </p>62 </Form>63 );64}6566// 通过 params 可以访问到 Route path 中的动态参数67export const loader = async ({ params }: LoaderFunctionArgs) => {68 const contact = await getContact(params.id!)69 return { contact }70}7172export default EditContact;2、在src/main.tsx中新增路由规则:
xxxxxxxxxx241import EditContact, { loader as editContactLoader } from '@/components/edit'23const router = createBrowserRouter([4 {5 path: '/',6 element: <Root />,7 errorElement: <ErrorPage />,8 loader: rootLoader,9 action: rootAction,10 children: [11 {12 path: "contacts/:id",13 element: <Contact />,14 loader: contactLoader15 },16 // 新增子路由17 {18 path: "contacts/:id/edit",19 element: <EditContact />,20 loader: editContactLoader21 }22 ]23 },24])3、点击详情页面的edit按钮,就可以跳转到相应的页面了:


需求:点击编辑页面的Save按钮,可以保存联系人的信息,并在保存成功后,跳转到原来的界面。
解决方法:在action中获取表单数据并修改联系人信息。
1、在src/components/edit.tsx中新增action函数:
xxxxxxxxxx31export const action = () => {2 return null;3}并挂载到路由上:
xxxxxxxxxx261// src/main.tsx23import EditContact, { loader as editContactLoader, action as editContactAction } from '@/components/edit'45const router = createBrowserRouter([6 {7 path: '/',8 element: <Root />,9 errorElement: <ErrorPage />,10 loader: rootLoader,11 action: rootAction,12 children: [13 {14 path: "contacts/:id",15 element: <Contact />,16 loader: contactLoader17 },18 {19 path: "contacts/:id/edit",20 element: <EditContact />,21 loader: editContactLoader,22 action: editContactAction23 }24 ]25 },26])2、在EditContact组件中,使用的是react-router-dom提供的Form组件,所以提交的数据会被action接收到:

先引入action参数的TS类型:
xxxxxxxxxx11import type { ActionFunctionArgs } from 'react-router-dom';然后在action中输出参数看一下:
xxxxxxxxxx41export const action = ({ request }: ActionFunctionArgs) => {2 console.log(request)3 return null4}
注意:
这里的Form表单并没有指定action,通过上面学习的知识,我可以知道action实际指向了组件在router里面注册的path,这一点很好理解。
但是Form里面有两个button,怎么确定点击Save按钮是触发表格提交的,而点击Cancel不会触发表格的提交呢?其实button的type属性就规定了能否触发,如果
type="submit"就会触发。
看一下输出即可知道,request本身上并不能直接得到表单数据,但是在它的原型上有一个formData函数,通过这个函数可以得到表单的数据,测试一下:
xxxxxxxxxx41export const action = ({ request }: ActionFunctionArgs) => {2 console.log(request.formData())3 return null4}
可以看到,request.formData()返回的是Promise对象,所以要用async...await来使用。
xxxxxxxxxx51export const action = async ({ request }: ActionFunctionArgs) => {2 const formdata = await request.formData();3 console.log(formdata)4 return null5}
由于提交数据的方法接收的是对象,而不是formData类型,所以需要将formData数据转为对象,使用Object.fromEntries()方法:
xxxxxxxxxx61export const action = async ({ request }: ActionFunctionArgs) => {2 const formdata = await request.formData();3 const form = Object.fromEntries(formdata)4 console.log(form)5 return null6}
可以看到,已经获取到想要的数据了。
3、从src/contacts.ts中引入更新联系人信息的方法:
xxxxxxxxxx11import { updateContact } from '@/contacts';在action函数中提交数据,更新数据的方法有两个参数,第一个参数是id,所以需要从params获取到id:
xxxxxxxxxx61export const action = async ({ request, params }: ActionFunctionArgs) => {2 const formdata = await request.formData();3 const form = Object.fromEntries(formdata)4 await updateContact(params.id!, form)5 return null6}可以看一下ActionFunctionArgs类型,有没有params参数?
是有的,可以使用。

可以看到,在保存数据之后,sidebar里面的信息也自动更新了,这再次说明了action的执行顺序。
4、使用react-router-dom提供的redirect方法,在提交完数据之后,返回原页面:
xxxxxxxxxx131export const action = async ({ request, params }: ActionFunctionArgs) => {2 // 1. request.formData 是一个函数,需要使用 request.formData() 来调用3 // 2. request.formData() 的返回值是 Promise 对象,需要使用 async...await 来拿到真正的 formData 对象4 const formdata = await request.formData();5 // console.log(formdata.get('first')) // 普通的 formData 对象,需要使用 .get() 方法来获取具体的值,但这样非常不方便,因为表单中的值有很多。6 // 可以使用下面的方法,将 formData 转成对象7 const form = Object.fromEntries(formdata)8 // 提交数据9 await updateContact(params.id!, form)1011 // 使用react-router-dom提供的 redirect('path') 函数,跳转到具体的地址12 return redirect("/contacts/" + params.id)13}
可以看到,在点击Save更新成功之后,就自动跳转到原页面了。
在新增时,可以拿到返回值里面的id,使用react-router-dom提供的redirect,重定向到Edit组件。

在src/components/root.tsx中,修改action函数,重定向到Edit组件:
xxxxxxxxxx71import { redirect } from "react-router-dom"2import { createContact } from "@/contacts"34export const action = async () => {5 const { id } = await createContact();6 return redirect(`/contacts/${id}/edit`)7}
可以看到,整个流程都顺畅了。
随着左侧菜单中数据的增多,我们很难准确分辨出哪个链接被点击了,因此,我们可以使用
NavLink代替Link组件。因为
NavLink组件可以使用className属性控制链接的样式。
1、在src/components/root.tsx中,从react-router-dom中按需导入NavLink组件:
xxxxxxxxxx11import { Outlet, NavLink, useLoaderData, Form, redirect } from 'react-router-dom'2、使用NavLink替换src/components/root.tsx中的Link组件:
xxxxxxxxxx201// 定义根据contacts渲染的元素2const renderContacts = () => {3 if (contacts.length === 0) {4 return <p><i>No contacts!</i></p>5 }6 return (7 <ul>8 {9 contacts.map((item) => {10 return <li key={item.id}>11 <NavLink to={`/contacts/${item.id}`}>12 {item.first || item.last ? <>{item.first} {item.last}</> : <i>No Name</i>}13 {item.favorite && <span>☆</span>}14 </NavLink>15 </li>16 })17 }18 </ul>19 )20}3、请注意:正在渲染中的路由对应的链接会添加pending类名,被选中的NavLink默认会添加active类名。

请注意看右侧元素上的class类名,从pending类名转到了active类名。
4、使用NavLink的className属性,自定义active和pending样式。
xxxxxxxxxx211// 定义根据contacts渲染的元素2const renderContacts = () => {3 if (contacts.length === 0) {4 return <p><i>No contacts!</i></p>5 }6 return (7 <ul>8 {9 contacts.map((item) => {10 return <li key={item.id}>11 {/* className属性的函数有两个参数:isActive 和 isPending,通过三元表达式来设置类名 */}12 <NavLink to={`/contacts/${item.id}`} className={({ isActive, isPending }) => isActive ? 'my-active' : isPending ? 'my-pending' : ''}>13 {item.first || item.last ? <>{item.first} {item.last}</> : <i>No Name</i>}14 {item.favorite && <span>☆</span>}15 </NavLink>16 </li>17 })18 }19 </ul>20 )21}
在src/index.css中设置样式:
xxxxxxxxxx81#sidebar nav a.my-active {2 color: white;3 background-color: darkcyan;4}56#sidebar nav a.my-pending {7 background-color: lightcyan;8}
在进行路由切换时,会先执行下个路由的loader,此时当前路由的组件依然被展示在页面上。为了给用户一个明显的提示,表明当前组件不是最新的,我们需要用到useNavigation这个hook。
useNavigation会拿到路由的状态,即navigation.state,它有三个值:
因此,我们可以判断是否处于loading状态,从而使用CSS样式提示用户当前处理路由的pending状态:
1、修改src/components/root.tsx组件,从react-router-dom中按需导入useNavigationhook:
xxxxxxxxxx11import { Outlet, NavLink, useLoaderData, Form, redirect,useNavigation } from 'react-router-dom'2、在Root组件中获取路由的信息:
xxxxxxxxxx11const navigation = useNavigation();3、给id属性为detail的div添加className如下:
xxxxxxxxxx41{/** 这里的loading类名在index.css中已经定义了 */}2<div id="detail" className={navigation.state === 'loading' ? 'loading' : ''}>3 <Outlet />4</div>
可以看到一个过渡的泛白效果。
在用户信息页面中,我们预留了一个
Delete按钮。并提供了method="post"、action="destroy"、onSubmit事件处理函数。
1、在src/components/下创建delete.tsx模块,并声明如下的action:
xxxxxxxxxx31export const action = () => {2 return null3}2、在src/main.tsx中引入action,并注册删除功能对应的路由:

xxxxxxxxxx291import { action as deleteContactAction } from '@/components/delete'23const router = createBrowserRouter([4 {5 path: '/',6 element: <Root />,7 errorElement: <ErrorPage />,8 loader: rootLoader,9 action: rootAction,10 children: [11 {12 path: "contacts/:id",13 element: <Contact />,14 loader: contactLoader15 },16 {17 path: "contacts/:id/edit",18 element: <EditContact />,19 loader: editContactLoader,20 action: editContactAction21 },22 {23 path: "contacts/:id/destroy",24 // 因为删除操作之后,可以直接跳转到别的界面,而不需要展示删除成功的界面,所以element属性可以不写。25 action: deleteContactAction,26 }27 ]28 },29])3、在src/components/delete.tsx中导入参数的TS类型,使用deleteContact方法来删除联系人:
xxxxxxxxxx81import type { ActionFunctionArgs } from "react-router-dom"2import { redirect } from "react-router-dom"3import { deleteContact } from "@/contacts"45export const action = async ({ params }: ActionFunctionArgs) => {6 await deleteContact(params.id!)7 return redirect("/")8}效果:

1、如果我们在{path:"contact/:id/destroy",action:deleteContactAction}的action中向外抛出一个错误:
xxxxxxxxxx91import type { ActionFunctionArgs } from "react-router-dom"2import { redirect } from "react-router-dom"3import { deleteContact } from "@/contacts"45export const action = async ({ params }: ActionFunctionArgs) => {6 throw new Error("duang")7 await deleteContact(params.id!)8 return redirect("/")9}
2、这个错误最终会被path:"/"的errorElement捕获并处理,因此页面上显示的<ErrorPage />组件。此时用户无法进行其它操作,只能点击浏览器的后退按钮。
3、为了提高用户的体验,我们可以为每一个Route都提供一个专属的errorElement(虽然这不是必须的):
xxxxxxxxxx281const router = createBrowserRouter([2 {3 path: '/',4 element: <Root />,5 errorElement: <ErrorPage />,6 loader: rootLoader,7 action: rootAction,8 children: [9 {10 path: "contacts/:id",11 element: <Contact />,12 loader: contactLoader13 },14 {15 path: "contacts/:id/edit",16 element: <EditContact />,17 loader: editContactLoader,18 action: editContactAction19 },20 {21 path: "contacts/:id/destroy",22 // 因为删除操作之后,可以直接跳转到别的界面,而不需要展示删除成功的界面,所以element属性可以不写。23 action: deleteContactAction,24 errorElement: <p>Oops! There was an error.</p>25 }26 ]27 },28])
当用户首次进入App时,会看到右侧面板一片空白,为了解决此问题,我们可以使用react-router-dom的index Route。
1、在src/components/下新建index.tsx组件:
xxxxxxxxxx91export default function Index() {2 return (3 <p id="zero-state">4 This is a demo for React Router.5 <br />6 Check out <a href="https://reactrouter.com">the docs at reactrouter.com</a>7 </p>8 )9}2、改造src/main.tsx组件中的代码,导入src/components/index.tsx组件:
xxxxxxxxxx11import Index from '@/components/index'为path:"/"的Route添加index子路由:
xxxxxxxxxx321const router = createBrowserRouter([2 {3 path: '/',4 element: <Root />,5 errorElement: <ErrorPage />,6 loader: rootLoader,7 action: rootAction,8 children: [9 // index Route10 {11 index: true,12 element: <Index />13 },14 {15 path: "contacts/:id",16 element: <Contact />,17 loader: contactLoader18 },19 {20 path: "contacts/:id/edit",21 element: <EditContact />,22 loader: editContactLoader,23 action: editContactAction24 },25 {26 path: "contacts/:id/destroy",27 action: deleteContactAction,28 errorElement: <p>Oops! There was an error.</p>29 }30 ]31 },32])
在点击详情页的Cancel时,想返回上一个页面,但是现在没有任何效果,可以使用react-router-dom提供的useNavigate来进行跳转。
1、改造src/components/edit.tsx组件,先按需导入useNavigatehook如下:
xxxxxxxxxx11import { Form, useLoaderData, redirect, useNavigate } from "react-router-dom";2、在EditContact组件中,调用useNavigatehook:
xxxxxxxxxx11const navigate = useNavigate();3、为<button type="button">Cancel</button>按钮绑定点击事件处理函数:
xxxxxxxxxx11<button type="button" onClick={() => navigate(-1)}>Cancel</button>
1、改造src/components/root.tsx组件中的代码,将搜索框外层的<form>替换为<Form>组件:

这样用户在搜索框中触发搜索之后,会在url上拼接上query参数,并且会触发组件的重新渲染,从而可以在loader函数里面获取到url上面的query参数。
2、在loader函数里面,获取query参数,并查询:
xxxxxxxxxx61export const loader = async ({ request }: LoaderFunctionArgs) => {2 const url = new URL(request.url)3 const q = url.searchParams.get("q") || ""4 const contacts = await getContacts(q)5 return contacts6}
在这里其实我有一个疑问,就是搜索框所在的Form组件不是对应action函数吗?为什么这里的搜索框的Form提交之后,不是在action里面进行处理呢?
首先确认搜索框所在的Form组件触发提交之后,action函数会不会执行?通过在action函数里面打印内容,答案是不会触发。
那么第二点就可以回答了,因为本身就不会触发action函数,那就不能在action函数里面处理了。
那么第一点,Form组件一定是对应action函数吗?不一定,因为搜索框所在的Form组件没有提供method属性,所以默认就是GET方法,而GET方法是不能触发action函数的:https://reactrouter.com/en/main/route/action
那么最后一个疑问:如果这个界面有多个操作,我要触发多个action函数,该怎么办?
很简单,就像Delete按钮一样,一个操作对应一个路由地址和action函数,多加几个路由就行了。
在搜索时,如果刷新网页,搜索框中的关键词会丢失,但搜索结果还在:

为什么刷新之后,搜索结果还在呢?因为刷新之后,url地址没有变化,loader会解析里面的查询参数,所以搜索结果还在。
为什么搜索框里面的值丢失了呢?因为刷新后,url地址指向了根路由,根组件Root会重新渲染,而搜索框里面并没有保存值,所以值会丢失。
可以为搜索框指定默认值来同步url与搜索框的值。
1、在loader函数中,将从url中获取到的搜索值返回出去:
xxxxxxxxxx81export const loader = async ({ request }: LoaderFunctionArgs) => {2 const url = new URL(request.url)3 const q = url.searchParams.get("q") || ""4 const contacts = await getContacts(q)5 return {6 contacts, q7 }8}2、使用useLoaderData来接收传递过来的搜索值,并赋值给搜索框:
xxxxxxxxxx261const Root: FC = () => {23 const { contacts, q } = useLoaderData() as { contacts: ContactType[], q: string };45 return (6 <>7 <div id="sidebar">8 <h1>React Router Contacts</h1>9 <div>10 <Form id="search-form" role="search">11 {/* 可以通过 defaultValue 属性,为文本框设置默认值。 */}12 <input13 id="q"14 aria-label="Search contacts"15 placeholder="Search"16 type="search"17 name="q"18 defaultValue={q}19 />20 {/* 省略其余代码 */}21 </Form>22 </div>23 </div>24 </>25 );26}效果:

在点击浏览器的前进后退按钮时,URL和搜索框里面的值没有同步,但搜索结果还是正常的:

说明input框里面defaultValue拿到的q值,是最新的。原因是input框里面defaultValue值的改变,并不能触发react组件的重新渲染,所以值没有及时更新。
解决方法一:
为input框添加key值,赋值为q,react对key值的更新,会监听并触发组件重新渲染:
xxxxxxxxxx101{/* 可以通过 defaultValue 属性,为文本框设置默认值。 */}2<input3 id="q"4 aria-label="Search contacts"5 placeholder="Search"6 type="search"7 name="q"8 defaultValue={q}9 key={q}10 />解决方法二:
使用useEffect监听q值的变化,为input赋值:
xxxxxxxxxx41useEffect(() => {2 const ipt = document.getElementById("q") as HTMLInputElement;3 ipt.value = q;4}, [q])效果:

解决方法一是老师推荐的方法,使用很简单。解决方法二是react-router-dom官方文档的tutorial里面的写法,理解起来很简单。
在搜索框输入文字后,点击enter键才能触发Form的提交,但是我们想搜索框里面边输入边搜索,那么需要为input绑定onChange事件,并且用到react-router-dom的useSubmit方法来触发Form的提交。
1、在src/components/root.tsx中引入useSubmit方法:
xxxxxxxxxx11import { useSubmit } from 'react-router-dom'2、在input的onChange事件中使用useSubmit方法:
xxxxxxxxxx381const Root: FC = () => {2 const submit = useSubmit();34 return (5 <>6 <div id="sidebar">7 <h1>React Router Contacts</h1>8 <div>9 <Form id="search-form" role="search">10 <input11 id="q"12 aria-label="Search contacts"13 placeholder="Search"14 type="search"15 name="q"16 defaultValue={query}17 key={query}18 {/*绑定onChange事件*/}19 onChange={event => {20 submit(event.currentTarget.form)21 }}22 />23 <div24 id="search-spinner"25 aria-hidden26 hidden={true}27 />28 <div29 className="sr-only"30 aria-live="polite"31 ></div>32 </Form>33 {/*省略其余代码*/}34 </div>35 </div>36 </>37 );38}
效果:

这里有一个问题,就是搜索框输入之后,搜索框是返回最新结果了,但是搜索框失去了焦点,如果用户想输入多个字符来搜索,这样肯定是非常麻烦的,因此必须要在搜索框重新渲染之后,自动聚焦。
可以在input搜索框上设置autoFocus属性即可自动聚焦。但还有个问题,就是中文输入法下面,如果输入中文,不等输入完成,就会自动触发搜索,这个问题还没有找到答案。
问题:
这个问题的可能原因是没有节流搜索,导致页面渲染出错。加上节流试试看。
xxxxxxxxxx611import { FC } from 'react'2import { useNavigation } from 'react-router-dom'34const Root: FC = () => {5 const { contacts: data, query } = useLoaderData() as { contacts: ContactType[]; query: string }6 console.log('渲染执行了,contacts = ', data)78 const navigation = useNavigation();910 const submit = useSubmit();1112 // 为什么判断条件这样写?首先搜索框是Form组件包裹的,在搜索框里面输入之后,会触发Form表单的提交,提交的地址是所在组件的router的path地址,也就是 / 。13 // 那么提交之后,Form没有指定method,就是默认的GET,会触发组件的重新渲染,loader会先执行,然后才是组件的重新渲染。此时的url上已经有q的参数了,那么这时候14 // 重新渲染时的searching就会为 true ,这时候就会显示loading状态,但是此时拿到的 useLoaderData() 不是最新的值,当loader里面的值真正返回之后,就会触发组件的重新渲染,此时的navigation.location为undefined,所以searching就是undefined,loading状态就会取消。详细效果可以看动图。15 const searching = navigation.location && new URLSearchParams(navigation.location.search).has("q")16 console.log('navigation.location = ',navigation.location)17 console.log('searching的值为 ', searching)1819 return (20 <>21 <div id="sidebar">22 <h1>React Router Contacts</h1>23 <div>24 <Form id="search-form" role="search">25 <input26 id="q"27 {/*添加类名*/}28 className={searching ? 'loading' : ''}29 {/*省略其余代码*/}30 />31 <div32 id="search-spinner"33 aria-hidden34 {/*根据searching判断loading是否显示*/}35 hidden={!searching}36 />37 <div38 className="sr-only"39 aria-live="polite"40 ></div>41 </Form>42 <Form method="post" action='/'>43 <button type="submit">New</button>44 </Form>45 </div>46 {/*省略其余代码*/}47 </div>48 </>49 );50}5152export const loader = async ({ request }: LoaderFunctionArgs) => {53 console.log('loader执行了')54 const url = new URL(request.url);55 const query = url.searchParams.get("q") || "";56 const contacts = await getContacts(query);57 console.log('返回的值是 contacts = ', contacts)58 return {59 contacts, query60 }61}

这里的“不产生导航”是什么意思???
之前在创建、修改、删除时,都创建了相应的action,分别是RootAction、EditContactAction、DeleteContactAction,这些action都对应了相应的router的path,在触发的时候,,,,,写不下去了
像delete按钮那样,就是写了一个导航地址,然后配置action函数来到到目的。这里的意思是在不写导航地址的前提下直接写一个action来修改数据。
1、在src/components/contact.tsx里面引入useFetcher:
xxxxxxxxxx11import { useFetcher } from "react-router-dom"2、将Favorite组件的Form改为fetcher.Form:
xxxxxxxxxx221export const Favorite: FC<{ contact: ContactType }> = ({ contact }) => {2 // 实例化useFetcher3 const fetcher = useFetcher();4 5 const favorite = contact.favorite;6 return (7 {/*使用 fetcher.Form */}8 <fetcher.Form method="post">9 <button10 name="favorite"11 value={favorite ? "false" : "true"}12 aria-label={13 favorite14 ? "Remove from favorites"15 : "Add to favorites"16 }17 >18 {favorite ? "★" : "☆"}19 </button>20 </fetcher.Form>21 );22}3、编写action函数,并引入到src/main.tsx文件中:
xxxxxxxxxx101// src/components/contact.tsx23export const action = async ({ request, params }: ActionFunctionArgs) => {4 const formData = await request.formData();5 6 // 更新状态7 return updateContact(params.id!, {8 favorite: formData.get("favorite") === "true",9 })10}xxxxxxxxxx261// src/main.tsx23import Contract, { loader as ContactLoader, action as ContactAction } from '@/components/contract'45const router = createBrowserRouter([6 {7 path: "/",8 element: <Root />,9 errorElement: <ErrorPage />,10 loader: RootLoader,11 action: RootAction,12 children: [13 {14 index: true,15 element: <Index />16 },17 {18 path: "contacts/:id",19 element: <Contract />,20 loader: ContactLoader,21 action: ContactAction,22 },23 // 省略其余代码24 ]25 },26])4、在src/components/root.tsx中,添加favorite状态的展示:
xxxxxxxxxx2812const renderContacts = () => {3 if (data.length === 0) {4 return <p>5 <i>No contacts.</i>6 </p>7 }8 return (9 <ul>10 {11 data.map(item => {12 return (13 <li key={item.id}>14 <NavLink to={`/contacts/${item.id}`} className={({ isActive, isPending }) => isActive ? 'my-active' : isPending ? 'my-pending' : ''}>15 {item.first || item.last ? <>{item.first} {item.last}</> : <i>No Name</i>}16 17 {/*添加元素,展示状态*/}18 <i>{item.favorite ? <span style={{color:'#eeb004'}}>★</span> : ""}</i>19 20 21 </NavLink>22 </li>23 )24 })25 }26 </ul>27 )28 }
效果:

在上个案例中,当点击favorite按钮之后,要等到数据真实更新之后,重新渲染才能得到点击之后的效果。为了给用户更好的反馈体验,当点击favorite按钮之后,可以直接将五角星的状态进行更改,即使数据最终没有更新,那么在真实数据返回之后,也可以更改过来。
可以使用fetcher.state来显示loading状态,像navigation.state一样的用法。但是这里可以使用fetcher.formData来获取到form data提交给action的值,立刻更新五角星的状态。
xxxxxxxxxx281// src/components/contact.tsx23export const Favorite: FC<{ contact: ContactType }> = ({ contact }) => {4 const fetcher = useFetcher();5 let favorite = contact.favorite;67 // 官方原话:The fetcher knows the form data being submitted to the action, so it's available to you on fetcher.formData. We'll use that to immediately update the star's state, even though the network hasn't finished.8 // 从fetcher可以获取到提交到action的form data,如果有form data,则立即更新五角星的状态。然后action真正执行完成之后,注意这里的“真正”二字,这是精髓,fetcher里面的form data会清除,组件会重新渲染。9 if (fetcher.formData) {10 favorite = fetcher.formData.get("favorite") === 'true';11 }1213 return (14 <fetcher.Form method="post">15 <button16 name="favorite"17 value={favorite ? "false" : "true"}18 aria-label={19 favorite20 ? "Remove from favorites"21 : "Add to favorites"22 }23 >24 {favorite ? "★" : "☆"}25 </button>26 </fetcher.Form>27 );28}效果:

代码很简单,但这里其实涉及到很重要的流程问题,一定要弄清楚:我点击favorite按钮之后,到底是怎么执行的?
我在组件内、loader函数、action函数里面都输出一下,看执行顺序到底是什么样的?
可以看到,组件在action函数触发之后,重新渲染了一次,然后在loader之后再次重新渲染,最后拿到最新的值时,又渲染一次,总共渲染了三次。
这是官方的说明:
在url中访问并不存在的contact,会返回这样的界面:

这个错误被errorElement捕获并处理,但是有时候可以让界面的信息更明确一些,如果在loader函数或者action函数中有任何错误,都可以使用throw来抛出错误,这样react router会捕获到,从而渲染errorElement的组件:

xxxxxxxxxx121// src/components/contact.tsx23export const loader = async ({ params }: LoaderFunctionArgs) => {4 const contact = await getContact(params.id!)5 if (!contact) {6 throw new Response("", {7 status: 404,8 statusText: "Not Found"9 })10 }11 return { contact }12}
上面的案例中,错误页占满了整个页面,这是因为错误最终被根路由的errorElement捕获到了,如果想要在<Outlet />显示的范围内显示错误页,则需要为每个子路由配置errorElement属性,这样虽然可以做到很好的效果,但是对于一些共同的错误页内容来说,配置起来就很麻烦:
xxxxxxxxxx341const router = createBrowserRouter([2 {3 path: "/",4 element: <Root />,5 errorElement: <ErrorPage />,6 loader: RootLoader,7 action: RootAction,8 children: [9 {10 index: true,11 element: <Index />12 },13 {14 path: "contacts/:id",15 element: <Contract />,16 loader: ContactLoader,17 action: ContactAction,18 errorElement: <ErrorPage />,19 },20 {21 path: "contacts/:id/edit",22 element: <EditContact />,23 loader: EditContactLoader,24 action: EditContactAction,25 errorElement: <ErrorPage />,26 },27 {28 path: "contacts/:id/destroy",29 action: DeleteContactAction,30 errorElement: <ErrorPage />,31 }32 ]33 },34])有没有办法能够一次性为多个子路由配置错误页呢?可以,children属性数组的值需要改写:
xxxxxxxxxx361const router = createBrowserRouter([2 {3 path: "/",4 element: <Root />,5 errorElement: <ErrorPage />,6 loader: RootLoader,7 action: RootAction,8 children: [9 {10 errorElement: <ErrorPage />,11 children: [12 {13 index: true,14 element: <Index />15 },16 {17 path: "contacts/:id",18 element: <Contract />,19 loader: ContactLoader,20 action: ContactAction,21 },22 {23 path: "contacts/:id/edit",24 element: <EditContact />,25 loader: EditContactLoader,26 action: EditContactAction,27 },28 {29 path: "contacts/:id/destroy",30 action: DeleteContactAction,31 }32 ]33 }34 ]35 },36])

很多人喜欢将路由写成jsx的形式,这是react-router-dom之前版本的写法,新版本也支持,需要使用createRoutesFromElements来创建,并使用Route标签。
xxxxxxxxxx181import { createBrowserRouter, createRoutesFromElements, RouterProvider, Route } from 'react-router-dom'23const router = createBrowserRouter(4 createRoutesFromElements(5 <Route path='/' element={<Root />} loader={RootLoader} action={RootAction} errorElement={<ErrorPage />}>6 <Route errorElement={<ErrorPage />}>7 <Route index element={<Index />}></Route>8 <Route path='contacts/:id' element={<Contract />} loader={ContactLoader} action={ContactAction}></Route>9 <Route path='contacts/:id/edit' element={<EditContact />} loader={EditContactLoader} action={EditContactAction}></Route>10 <Route path='contacts/:id/destroy' action={DeleteContactAction}></Route>11 </Route>12 </Route>13 )14)1516ReactDOM.createRoot(document.getElementById('root')!).render(17 <RouterProvider router={router} />18)
这个操作也很简单,只需要把router相关的代码全部放到一个组件中,然后在main.tsx中引入并使用即可。
1、在src/components中新建router.tsx文件,将路由配置代码全部放到这个组件中。
xxxxxxxxxx211import { createBrowserRouter, createRoutesFromElements, Route } from 'react-router-dom'23import Root, { loader as RootLoader, action as RootAction } from '@/components/root'4import ErrorPage from '@/error-page'5import Contract, { loader as ContactLoader, action as ContactAction } from '@/components/contract'6import EditContact, { loader as EditContactLoader, action as EditContactAction } from '@/components/edit'7import { action as DeleteContactAction } from '@/components/delete'8import Index from '@/components/index'910export const router = createBrowserRouter(11 createRoutesFromElements(12 <Route path='/' element={<Root />} loader={RootLoader} action={RootAction} errorElement={<ErrorPage />}>13 <Route errorElement={<ErrorPage />}>14 <Route index element={<Index />}></Route>15 <Route path='contacts/:id' element={<Contract />} loader={ContactLoader} action={ContactAction}></Route>16 <Route path='contacts/:id/edit' element={<EditContact />} loader={EditContactLoader} action={EditContactAction}></Route>17 <Route path='contacts/:id/destroy' action={DeleteContactAction}></Route>18 </Route>19 </Route>20 )21)2、在main.tsx中引入路由配置:
xxxxxxxxxx81import ReactDOM from 'react-dom/client'2import '@/index.css'3import { RouterProvider } from 'react-router-dom'4import { router } from '@/components/router'56ReactDOM.createRoot(document.getElementById('root')!).render(7 <RouterProvider router={router} />8)效果OK。
自己的一些疑问:
有一个问题:在搜索后的sidebar里面,点击某一项,那么sidebar就会重新渲染,搜索框里面的内容也丢失了。

根本原因是q这个值丢失了,由于q是从url上获取的,那么点击sidebar的某一项导航后,跳转到了另外一个url地址,肯定不能从这个url上获取搜索条件,那么其实root.tsx里面获取的q在真实项目中还需要加判断条件,这里先不管。
那么只需要把q这个值放到zustand或者redux里面就行了,用的时候就拿。
改写loader函数:
xxxxxxxxxx111export const loader = async ({ request }: LoaderFunctionArgs) => {2 const url = new URL(request.url);3 let q = url.searchParams.get("q") || localStorage.getItem("q");4 if (q) {5 localStorage.setItem("q", q);6 } else {7 q = "";8 }9 const contacts = await getContacts(q);10 return { contacts, q };11}这样就好了:

但还是有问题,当搜索框清空之后,搜索结果不对,原因是搜索框清空之后,存储的数据没有清空,需要重新设计判断条件:
xxxxxxxxxx191export const loader = async ({ request }: LoaderFunctionArgs) => {2 const url = new URL(request.url);34 let q = url.searchParams.get("q") || "";5 if (q) {6 localStorage.setItem("q", q);7 } else {8 // 优化判断条件,只有在这个url地址搜索的时候,搜索框里面如果清空了,就将存储的数据清空。9 if (url.search.slice(0, 3) === '?q=') {10 q = "";11 localStorage.setItem("q", "");12 } else {13 // 如果在别的url地址下,那么从url就获取不到q的值,可以从localStrage获取。14 q = localStorage.getItem("q") || "";15 }16 }17 const contacts = await getContacts(q);18 return { contacts, q };19}
在root.tsx中,Root的代码很复杂,那么能不能把获取contacts数据渲染组件的代码,放到一个单独的组件中呢?这样还可以使用React.memo来优化,如果数据没有变化,那么组件就不用重新渲染。
xxxxxxxxxx931// src/components/root.tsx23export const Root: FC = () => {45 const { q } = useLoaderData() as { contacts: ContactType[]; q: string }67 const navigation = useNavigation();89 const submit = useSubmit();1011 const searching = navigation.location && new URLSearchParams(navigation.location.search).has('q')1213 return (14 <>15 <div id="sidebar">16 <h1>React Router Contacts</h1>17 <div>18 <Form id="search-form" role="search">19 <input20 id="q"21 className={searching ? 'loading' : ''}22 aria-label="Search contacts"23 placeholder="Search"24 type="search"25 name="q"26 defaultValue={q}27 key={q}28 autoFocus29 onChange={(event) => {30 submit(event.currentTarget.form)31 }}32 />33 <div34 id="search-spinner"35 aria-hidden36 hidden={!searching}37 />38 <div39 className="sr-only"40 aria-live="polite"41 ></div>42 </Form>43 <Form method="post">44 <button type="submit">New</button>45 </Form>46 </div>47 <nav>48 <UlDom />49 </nav>50 </div>51 <div id="detail" className={navigation.state === 'loading' ? 'loading' : ''}>52 <Outlet />53 </div>54 </>55 );56}5758const UlDom: FC = React.memo(59 () => {60 const { contacts } = useLoaderData() as { contacts: ContactType[]; q: string }6162 const renderDom = () => {63 if (contacts.length === 0) {64 return <>65 <i>No Contacts.</i>66 </>67 }68 return (69 <ul>70 {71 contacts.map(item => {72 return <li key={item.id}>73 <NavLink to={`/contacts/${item.id}`} className={({ isActive, isPending }) => {74 return isActive ? 'my-active' : isPending ? 'my-pending' : ''75 }}>76 {item.first || item.last ? item.first + " " + item.last : <i>No Name</i>}77 <span style={{ color: 'yellow' }}>{item.favorite && '★'}</span>78 </NavLink>79 </li>80 })81 }82 </ul>83 )84 }85 console.log('渲染了UlDOM组件')8687 return (88 <>89 {renderDom()}90 </>91 )92 }93)在main.tsx文件中,注册的路由是这样的:
xxxxxxxxxx151const router = createBrowserRouter([2 {3 path: "/",4 element: <Root />,5 errorElement: <ErrorPage />,6 loader: RootLoader,7 action: RootAction,8 children: [9 {10 errorElement: <ErrorPage />,11 children: [12 // 省略其余代码13 ]14 },15])element属性的值是:<Root />,loader和action都在同层级定义,是不是意味着只能在Root组件的函数代码里面使用loader和action相关的api呢?可以这么说,但是如果一个子组件本身就是要用在这个组件里面的,那当然本身就属于这个组件的一部分,使用loader和action相关的api也是没有问题的。
可以看到功能是正常的,但是memo并没有起作用,是什么原因呢?

目前所做的项目,路由还是有些复杂的,react中该怎么做呢?


首先要搞清楚菜单的组成,这个问题不先搞清楚,越做越糊涂。
菜单是由真正的路由和目录组成的,像上图中的活动管理就是目录名称,而里面的“活动信息”、“活动报名”、“活动报名申请”、“活动报名审核”这些才是真正的路由。
这里的菜单只有两层结构,如果有更多层,也是这样的组成。
那么点击目录名称,UI交互就只有收起和展开里面的内容,不需要进行高亮显示、不会进行跳转。而点击路由,就会有高亮显示,并且会跳转到相应的页面。
那么真正的路由就可以使用NavLink来包裹(NavLink本身就可以设置高亮效果,都不需要另外设置了,这很好)。目录就可以使用div标签来表示,div上绑定事件即可,也很简单。这里我想说,如果是直接使用固定的路由标签来写,会很复杂,团队开发时更改也很难,所以还是需要做一个路由的维护界面,将路由数据改造成js对象,然后在真正生成路由的时候,根据这个数据来渲染生成即可,这样的效果是最好的。下面是vue项目的路由例子,很多地方可以参考,比如说hasChildren为true、urlAddress为空的话,就表示这是一个目录,就渲染为div,别的情况就渲染为NavLink。
xxxxxxxxxx1041{2 "menuList": [3 {4 "id": "512640921863454917",5 "fullName": "会员管理",6 "enCode": "member",7 "parentId": "-1",8 "icon": "icon-ym icon-ym-accountConfig",9 "hasChildren": true,10 "urlAddress": "",11 "linkTarget": "_self",12 "children": [13 {14 "id": "512646130547294405",15 "fullName": "入会申请",16 "enCode": "trade.member.app",17 "parentId": "512640921863454917",18 "icon": "icon-ym icon-ym-generator-founder",19 "hasChildren": false,20 "urlAddress": "member/app",21 "linkTarget": "_self",22 "children": null,23 "type": 2,24 "propertyJson": "{\"iconBackgroundColor\":\"\",\"isTree\":0,\"moduleId\":\"\"}",25 "sortCode": 026 },27 {28 "id": "512643644629450949",29 "fullName": "入会审核",30 "enCode": "member-verify",31 "parentId": "512640921863454917",32 "icon": "icon-ym icon-ym-flowEntrust",33 "hasChildren": false,34 "urlAddress": "member/memberVerify",35 "linkTarget": "_self",36 "children": null,37 "type": 2,38 "propertyJson": "{\"moduleId\":\"\",\"iconBackgroundColor\":\"\",\"isTree\":0}",39 "sortCode": 140 },41 {42 "id": "512644067742449861",43 "fullName": "会员信息",44 "enCode": "member-info",45 "parentId": "512640921863454917",46 "icon": "ym-custom ym-custom-account-card-details",47 "hasChildren": false,48 "urlAddress": "member/memberInfo",49 "linkTarget": "_self",50 "children": null,51 "type": 2,52 "propertyJson": "{\"moduleId\":\"\",\"iconBackgroundColor\":\"\",\"isTree\":0}",53 "sortCode": 254 }55 ],56 "type": 1,57 "propertyJson": "{\"moduleId\":\"\",\"iconBackgroundColor\":\"\",\"isTree\":0}",58 "sortCode": 059 },60 {61 "id": "512644439336812741",62 "fullName": "会员福利",63 "enCode": "member-welfare",64 "parentId": "-1",65 "icon": "ym-custom ym-custom-gift",66 "hasChildren": true,67 "urlAddress": "",68 "linkTarget": "_self",69 "children": [70 {71 "id": "512644653657358533",72 "fullName": "福利设置",73 "enCode": "welfare-setting",74 "parentId": "512644439336812741",75 "icon": "ym-custom ym-custom-gift",76 "hasChildren": false,77 "urlAddress": "welfare/welfareSetting",78 "linkTarget": "_self",79 "children": null,80 "type": 2,81 "propertyJson": "{\"moduleId\":\"\",\"iconBackgroundColor\":\"\",\"isTree\":0}",82 "sortCode": 083 },84 {85 "id": "512645116423307461",86 "fullName": "福利领取",87 "enCode": "welfare-receive",88 "parentId": "512644439336812741",89 "icon": "ym-custom ym-custom-wallet-giftcard",90 "hasChildren": false,91 "urlAddress": "welfare/welfareReceive",92 "linkTarget": "_self",93 "children": null,94 "type": 2,95 "propertyJson": "{\"moduleId\":\"\",\"iconBackgroundColor\":\"\",\"isTree\":0}",96 "sortCode": 097 }98 ],99 "type": 1,100 "propertyJson": "{\"moduleId\":\"\",\"iconBackgroundColor\":\"\",\"isTree\":0}",101 "sortCode": 1102 }103 ]104}可以自己实现一下,就用上面的模拟数据渲染一下,一定要做啊。
搞懂了路由的嵌套,就可以做很复杂的项目。
在学习vue-router的时候,我只学会了router.js里面该怎么写,但是具体的路由插槽却一直没有搞懂,所以自己创建vue项目就有些问题,比如说项目左边是菜单栏,如何根据左侧菜单栏,在右侧显示不同的页面呢?刚开始的时候根本就没有搞懂,我以为路由跳转了,并且在router.js里面该路由下注册了这个组件,然后就一定会显示这个组件。我忘记了写这个路由组件应该在哪里显示呢?这时候就要用到路由插槽,是要写在组件<template></template>里面的,在vue中就是<Router-view />,比如说根path对应的是"/",组件是root.vue,那么里面就要写成左边部分和右边部分。左边部分基本上可以固定写元素,但是右边部分就要加上<router-view />的插槽,这样根path下面的children渲染的时候,就可以在右侧进行显示。
react中也是同样的思路,如果想要通过路由跳转来切换组件的展示,那么除了在router.js里面进行定义之外,还需要在具体的父组件中的某个位置加上<Outlet />标签,作为路由插槽。下面以老师的react-article-admin-template项目来说明:
router.ts文件里面:

views/root/root.tsx文件里面:

组件的切换,不一定使用路由的跳转来实现,一般都是大的组件用路由切换,如果是小的组件切换,完全可以用{flag && xxComponent}来实现切换,vue中就是v-if或者v-show来实现。
路由插槽真的是一个很好的发明,在框架层面做到了路由跳转和监听渲染,用户只需要使用,就可以实现复杂的功能。